Откройте расширенные возможности обработки видео в браузере. Узнайте, как напрямую получать доступ и манипулировать сырыми данными плоскостей VideoFrame с помощью WebCodecs API для создания пользовательских эффектов и анализа.
Доступ к плоскостям VideoFrame в WebCodecs: Глубокое погружение в манипуляцию сырыми видеоданными
Годами высокопроизводительная обработка видео в веб-браузере казалась далекой мечтой. Разработчики часто были ограничены возможностями элемента <video> и 2D Canvas API, которые, хотя и были мощными, создавали узкие места в производительности и ограничивали доступ к базовым сырым видеоданным. Появление WebCodecs API коренным образом изменило эту ситуацию, предоставив низкоуровневый доступ к встроенным в браузер медиакодекам. Одной из самых революционных его функций является возможность прямого доступа и манипулирования сырыми данными отдельных видеокадров через объект VideoFrame.
Эта статья — исчерпывающее руководство для разработчиков, желающих выйти за рамки простого воспроизведения видео. Мы изучим тонкости доступа к плоскостям VideoFrame, разъясним такие понятия, как цветовые пространства и расположение в памяти, и предоставим практические примеры, которые позволят вам создавать следующее поколение браузерных видеоприложений, от фильтров в реальном времени до сложных задач компьютерного зрения.
Необходимые знания
Чтобы извлечь максимальную пользу из этого руководства, у вас должно быть четкое понимание следующих тем:
- Современный JavaScript: Включая асинхронное программирование (
async/await, Promises). - Основные концепции видео: Знакомство с такими терминами, как кадры, разрешение и кодеки, будет полезным.
- Браузерные API: Опыт работы с API, такими как Canvas 2D или WebGL, будет преимуществом, но не является строго обязательным.
Понимание видеокадров, цветовых пространств и плоскостей
Прежде чем мы углубимся в API, мы должны сначала составить четкую мысленную модель того, как на самом деле выглядят данные видеокадра. Цифровое видео — это последовательность неподвижных изображений, или кадров. Каждый кадр — это сетка пикселей, и каждый пиксель имеет цвет. Способ хранения этого цвета определяется цветовым пространством и пиксельным форматом.
RGBA: «Родной язык» веба
Большинство веб-разработчиков знакомы с цветовой моделью RGBA. Каждый пиксель представлен четырьмя компонентами: красным (Red), зеленым (Green), синим (Blue) и альфа-каналом (прозрачность, Alpha). Данные обычно хранятся в памяти с чередованием (interleaved), что означает, что значения R, G, B и A для одного пикселя хранятся последовательно:
[R1, G1, B1, A1, R2, G2, B2, A2, ...]
В этой модели все изображение хранится в одном непрерывном блоке памяти. Можно считать, что у нас есть одна «плоскость» данных.
YUV: Язык сжатия видео
Однако видеокодеки редко работают напрямую с RGBA. Они предпочитают цветовые пространства YUV (или, точнее, Y'CbCr). Эта модель разделяет информацию об изображении на:
- Y (Luma): Яркость или информация в оттенках серого. Человеческий глаз наиболее чувствителен к изменениям яркости.
- U (Cb) и V (Cr): Цветность или информация о цветовой разнице. Человеческий глаз менее чувствителен к деталям цвета, чем к деталям яркости.
Это разделение является ключом к эффективному сжатию. Уменьшая разрешение компонентов U и V — техника, называемая цветовой субдискретизацией (chroma subsampling) — мы можем значительно уменьшить размер файла с минимальной заметной потерей качества. Это приводит к планарным пиксельным форматам, где компоненты Y, U и V хранятся в отдельных блоках памяти, или «плоскостях».
Распространенным форматом является I420 (тип YUV 4:2:0), где на каждый блок пикселей 2x2 приходится четыре сэмпла Y, но только по одному сэмплу U и V. Это означает, что плоскости U и V имеют половину ширины и половину высоты плоскости Y.
Понимание этого различия критически важно, потому что WebCodecs дает вам прямой доступ именно к этим плоскостям, в том виде, в котором их предоставляет декодер.
Объект VideoFrame: Ваш портал к пиксельным данным
Центральным элементом этой головоломки является объект VideoFrame. Он представляет один кадр видео и содержит не только пиксельные данные, но и важные метаданные.
Ключевые свойства VideoFrame
format: Строка, указывающая пиксельный формат (например, 'I420', 'NV12', 'RGBA').codedWidth/codedHeight: Полные размеры кадра, как они хранятся в памяти, включая любое выравнивание, требуемое кодеком.displayWidth/displayHeight: Размеры, которые следует использовать для отображения кадра.timestamp: Временная метка представления кадра в микросекундах.duration: Длительность кадра в микросекундах.
Волшебный метод: copyTo()
Основным методом для доступа к сырым пиксельным данным является videoFrame.copyTo(destination, options). Этот асинхронный метод копирует данные плоскостей кадра в предоставленный вами буфер.
destination:ArrayBufferили типизированный массив (например,Uint8Array), достаточно большой, чтобы вместить данные.options: Объект, который указывает, какие плоскости копировать и их расположение в памяти. Если он опущен, копируются все плоскости в один непрерывный буфер.
Метод возвращает Promise, который разрешается массивом объектов PlaneLayout, по одному для каждой плоскости в кадре. Каждый объект PlaneLayout содержит две критически важные части информации:
offset: Смещение в байтах, с которого начинаются данные этой плоскости в буфере назначения.stride: Количество байтов между началом одной строки пикселей и началом следующей строки для этой плоскости.
Критически важное понятие: Stride (шаг строки) и Width (ширина)
Это один из самых частых источников путаницы для разработчиков, плохо знакомых с низкоуровневым программированием графики. Нельзя предполагать, что каждая строка пиксельных данных плотно упакована одна за другой.
- Width (ширина) — это количество пикселей в строке изображения.
- Stride (шаг строки, также называемый pitch или line step) — это количество байтов в памяти от начала одной строки до начала следующей.
Часто stride будет больше, чем width * bytes_per_pixel. Это связано с тем, что память часто выравнивается по границам оборудования (например, по 32- или 64-байтным границам) для более быстрой обработки центральным или графическим процессором. Вы всегда должны использовать stride для вычисления адреса пикселя в определенной строке памяти.
Игнорирование stride приведет к искаженным или деформированным изображениям и неверному доступу к данным.
Практический пример 1: Доступ и отображение монохромной плоскости
Начнем с простого, но мощного примера. Большинство видео в вебе кодируется в формате YUV, например I420. Плоскость 'Y' фактически является полным монохромным представлением изображения. Мы можем извлечь только эту плоскость и отобразить ее на canvas.
async function displayGrayscale(videoFrame) {
// Предполагаем, что videoFrame имеет формат YUV, например 'I420' или 'NV12'.
if (!videoFrame.format.startsWith('I4')) {
console.error('Этот пример требует планарного формата YUV 4:2:0.');
videoFrame.close();
return;
}
const yPlaneInfo = videoFrame.layout[0]; // Плоскость Y всегда идет первой.
// Создаем буфер для хранения данных только Y-плоскости.
const yPlaneData = new Uint8Array(yPlaneInfo.stride * videoFrame.codedHeight);
// Копируем Y-плоскость в наш буфер.
await videoFrame.copyTo(yPlaneData, {
rect: { x: 0, y: 0, width: videoFrame.codedWidth, height: videoFrame.codedHeight },
layout: [yPlaneInfo]
});
// Теперь yPlaneData содержит сырые монохромные пиксели.
// Нам нужно его отрисовать. Создадим RGBA-буфер для canvas.
const canvas = document.getElementById('my-canvas');
canvas.width = videoFrame.displayWidth;
canvas.height = videoFrame.displayHeight;
const ctx = canvas.getContext('2d');
const imageData = ctx.createImageData(canvas.width, canvas.height);
// Итерируем по пикселям canvas и заполняем их данными из Y-плоскости.
for (let y = 0; y < videoFrame.displayHeight; y++) {
for (let x = 0; x < videoFrame.displayWidth; x++) {
// Важно: используйте stride для нахождения правильного индекса в источнике!
const yIndex = y * yPlaneInfo.stride + x;
const luma = yPlaneData[yIndex];
// Вычисляем индекс назначения в RGBA-буфере ImageData.
const rgbaIndex = (y * canvas.width + x) * 4;
imageData.data[rgbaIndex] = luma; // Красный
imageData.data[rgbaIndex + 1] = luma; // Зеленый
imageData.data[rgbaIndex + 2] = luma; // Синий
imageData.data[rgbaIndex + 3] = 255; // Альфа
}
}
ctx.putImageData(imageData, 0, 0);
// КРИТИЧЕСКИ ВАЖНО: Всегда закрывайте VideoFrame, чтобы освободить его память.
videoFrame.close();
}
Этот пример демонстрирует несколько ключевых шагов: определение правильного макета плоскости, выделение буфера назначения, использование copyTo для извлечения данных и правильная итерация по данным с использованием stride для создания нового изображения.
Практический пример 2: Манипуляция на месте (фильтр сепии)
Теперь давайте выполним прямую манипуляцию данными. Фильтр сепии — это классический эффект, который легко реализовать. Для этого примера проще работать с кадром в формате RGBA, который вы можете получить с canvas или из контекста WebGL.
async function applySepiaFilter(videoFrame) {
// Этот пример предполагает, что входной кадр имеет формат 'RGBA' или 'BGRA'.
if (videoFrame.format !== 'RGBA' && videoFrame.format !== 'BGRA') {
console.error('Пример с фильтром сепии требует кадра в формате RGBA.');
videoFrame.close();
return null;
}
// Выделяем буфер для хранения пиксельных данных.
const frameDataSize = videoFrame.allocationSize();
const frameData = new Uint8Array(frameDataSize);
await videoFrame.copyTo(frameData);
const layout = videoFrame.layout[0]; // RGBA имеет одну плоскость
// Теперь манипулируем данными в буфере.
for (let y = 0; y < videoFrame.codedHeight; y++) {
for (let x = 0; x < videoFrame.codedWidth; x++) {
const pixelIndex = y * layout.stride + x * 4; // 4 байта на пиксель (R,G,B,A)
const r = frameData[pixelIndex];
const g = frameData[pixelIndex + 1];
const b = frameData[pixelIndex + 2];
const tr = 0.393 * r + 0.769 * g + 0.189 * b;
const tg = 0.349 * r + 0.686 * g + 0.168 * b;
const tb = 0.272 * r + 0.534 * g + 0.131 * b;
frameData[pixelIndex] = Math.min(255, tr);
frameData[pixelIndex + 1] = Math.min(255, tg);
frameData[pixelIndex + 2] = Math.min(255, tb);
// Альфа (frameData[pixelIndex + 3]) остается без изменений.
}
}
// Создаем *новый* VideoFrame с измененными данными.
const newFrame = new VideoFrame(frameData, {
format: videoFrame.format,
codedWidth: videoFrame.codedWidth,
codedHeight: videoFrame.codedHeight,
timestamp: videoFrame.timestamp,
duration: videoFrame.duration
});
// Не забудьте закрыть оригинальный кадр!
videoFrame.close();
return newFrame;
}
Это демонстрирует полный цикл чтения-модификации-записи: копирование данных, итерация по ним с использованием stride, применение математического преобразования к каждому пикселю и создание нового VideoFrame с результирующими данными. Этот новый кадр затем можно отобразить на canvas, отправить в VideoEncoder или передать на следующий этап обработки.
Производительность имеет значение: JavaScript против WebAssembly (WASM)
Итерация по миллионам пикселей для каждого кадра (кадр 1080p содержит более 2 миллионов пикселей, или 8 миллионов точек данных в RGBA) в JavaScript может быть медленной. Хотя современные движки JS невероятно быстры, для обработки видео высокого разрешения (HD, 4K) в реальном времени этот подход может легко перегрузить основной поток, что приведет к прерывистому пользовательскому опыту.
Именно здесь WebAssembly (WASM) становится незаменимым инструментом. WASM позволяет вам запускать код, написанный на таких языках, как C++, Rust или Go, с почти нативной скоростью прямо в браузере. Рабочий процесс для обработки видео становится следующим:
- В JavaScript: Используйте
videoFrame.copyTo(), чтобы получить сырые пиксельные данные вArrayBuffer. - Передача в WASM: Передайте ссылку на этот буфер в ваш скомпилированный модуль WASM. Это очень быстрая операция, так как она не требует копирования данных.
- В WASM (C++/Rust): Выполните ваши высокооптимизированные алгоритмы обработки изображений непосредственно над буфером памяти. Это на порядки быстрее, чем цикл на JavaScript.
- Возврат в JavaScript: Как только WASM завершит работу, управление возвращается в JavaScript. Затем вы можете использовать измененный буфер для создания нового
VideoFrame.
Для любого серьезного приложения для манипуляции видео в реальном времени — такого как виртуальные фоны, обнаружение объектов или сложные фильтры — использование WebAssembly не просто вариант, а необходимость.
Обработка различных пиксельных форматов (например, I420, NV12)
Хотя RGBA прост, чаще всего вы будете получать кадры от VideoDecoder в планарных форматах YUV. Давайте посмотрим, как обрабатывать полностью планарный формат, такой как I420.
VideoFrame в формате I420 будет иметь три дескриптора макета в своем массиве layout:
layout[0]: Плоскость Y (яркость). Размеры:codedWidthxcodedHeight.layout[1]: Плоскость U (цветность). Размеры:codedWidth/2xcodedHeight/2.layout[2]: Плоскость V (цветность). Размеры:codedWidth/2xcodedHeight/2.
Вот как можно скопировать все три плоскости в один буфер:
async function extractI420Planes(videoFrame) {
const totalSize = videoFrame.allocationSize({ format: 'I420' });
const allPlanesData = new Uint8Array(totalSize);
const layouts = await videoFrame.copyTo(allPlanesData);
// layouts — это массив из 3 объектов PlaneLayout
console.log('Макет Y-плоскости:', layouts[0]); // { offset: 0, stride: ... }
console.log('Макет U-плоскости:', layouts[1]); // { offset: ..., stride: ... }
console.log('Макет V-плоскости:', layouts[2]); // { offset: ..., stride: ... }
// Теперь вы можете получить доступ к каждой плоскости внутри буфера `allPlanesData`
// используя ее конкретное смещение и шаг строки.
const yPlaneView = new Uint8Array(
allPlanesData.buffer,
layouts[0].offset,
layouts[0].stride * videoFrame.codedHeight
);
// Обратите внимание, что размеры цветовых компонент уменьшены вдвое!
const uPlaneView = new Uint8Array(
allPlanesData.buffer,
layouts[1].offset,
layouts[1].stride * (videoFrame.codedHeight / 2)
);
const vPlaneView = new Uint8Array(
allPlanesData.buffer,
layouts[2].offset,
layouts[2].stride * (videoFrame.codedHeight / 2)
);
console.log('Размер доступной Y-плоскости:', yPlaneView.byteLength);
console.log('Размер доступной U-плоскости:', uPlaneView.byteLength);
videoFrame.close();
}
Другой распространенный формат — NV12, который является полупланарным. Он имеет две плоскости: одну для Y, и вторую, где значения U и V чередуются (например, [U1, V1, U2, V2, ...]). WebCodecs API обрабатывает это прозрачно; VideoFrame в формате NV12 просто будет иметь два макета в своем массиве layout.
Сложности и лучшие практики
Работа на таком низком уровне предоставляет большие возможности, но и накладывает ответственность.
Управление памятью имеет первостепенное значение
VideoFrame удерживает значительный объем памяти, который часто управляется вне кучи сборщика мусора JavaScript. Если вы явно не освободите эту память, вы вызовете утечку памяти, которая может привести к сбою вкладки браузера.
Всегда, всегда вызывайте videoFrame.close(), когда вы закончили работу с кадром.
Асинхронная природа
Весь доступ к данным является асинхронным. Архитектура вашего приложения должна правильно обрабатывать поток Promise и async/await, чтобы избежать состояний гонки и обеспечить плавный конвейер обработки.
Совместимость с браузерами
WebCodecs — это современный API. Хотя он поддерживается во всех основных браузерах, всегда проверяйте его доступность и будьте в курсе любых специфичных для поставщика деталей реализации или ограничений. Используйте обнаружение функций перед попыткой использования API.
Заключение: Новый рубеж для веб-видео
Возможность прямого доступа и манипулирования сырыми данными плоскостей VideoFrame через WebCodecs API — это смена парадигмы для медиаприложений в вебе. Это убирает «черный ящик» элемента <video> и дает разработчикам гранулярный контроль, ранее доступный только в нативных приложениях.
Понимая основы расположения видео в памяти — плоскости, stride и цветовые форматы — и используя мощь WebAssembly для критически важных для производительности операций, вы теперь можете создавать невероятно сложные инструменты для обработки видео прямо в браузере. От цветокоррекции в реальном времени и пользовательских визуальных эффектов до машинного обучения на стороне клиента и видеоанализа — возможности огромны. Эра высокопроизводительного, низкоуровневого видео в вебе действительно началась.